0X00 前言

目前市面上有关扫描器的书籍大概就是这本 《白帽子讲 web 扫描》了,虽然知道区区 200 多页的书籍内容的深度和广度不会很高,但是还是介绍了一些开发扫描器过程中的基本方法和关键的坑点,对于我这种初学者也算是一本不错的入门级教材了,本文是阅读这本书的读书记录,作为备忘。

0X01 如何理解扫描器

1.概念以及原理

Web 扫描器其实是一种自动化的安全弱点和风险检测工具:它的工作方式和原理主要是通过分析HTTP (s) 请求和响应来发现安全问题和风险

2.作用以及目的

在对一个目标进行渗透测试时,首先需要进行信息收集,然后再对这些信息进行漏洞审计。其中,信息收集的目的是最大化地收集与目标有关联的信息,提供尽可能多的攻击入口漏洞审计则是对这些可能的攻击入口进行安全分析和检测, 来验证这些攻击入口是否可以被利用

由于这两个环节的工作更多是具有发散性的,因此人工的工作量就会非常大。 这个时候就我们需要用到Web扫描器,其实它的目的就是尽可能地帮助我们自动完成这两个环节,方便安全测 试人员快速获取目标可供利用的漏洞以便进行后续渗透工作。

3.扫描器的类型

(1)主动型

主动型的意思就是说,当对目标进行扫描时,扫描请求是主动发起的,所以称之为主动型,常见的有 AWVS Nessus 等

(2)被动型

不会向目标发送扫描请求,而是通过中间代理或流量镜像的方式,通过网络流量的真实请求去发现和告知可能存在的安全缺陷或漏洞。常见的有 GourdScan 和 NagaScan

(3)云端型

一些在线的扫描器,这里就不再列举了。

0X02 爬虫基础

1.HTTP 认证

爬虫在爬取资源的过程中,有时候会遇到 HTTP 认证的情况,也就是说,Web 服务器会对客户端的权限进行认证,只有认证通过才允许其访问服务端的资源。

(1) Basic 认证

Basic认证是HTTP常用的一种认证方式, 由于HTTP协议是无状态的, 所以客户端每次访问Web应用时,都要在请求的头部携带认证信息, 一般是用户名和密码, 如果验证不通过, 则会提示如下:

此处输入图片的描述

Basic认证的请求和响应, 抓包如下:

此处输入图片的描述

其中, HTTP请求中的Authorization字段包含着用户名和密码信息, Basic后面的一串字符:”YWRtaW46c2VjcmVO” 即为用户名和密码的 Base64 编码,解码后的内容为:admin:secret。

从上面的描述中,我们可以看到,Basic 认证的缺点很明显,它是按照明文信息进行传递的,因此很容易被中间人劫持获取。

(2) Digest认证(摘要式)

Digest 认证其实是一种基于挑战-应答模式的认证模型,它比 Basic 更安全。为了防止重放攻击,客户端在发送第一个请求后,会受到一个状态码为 401 的响应,响应内容包含一个唯一的字符串 : nonce ,且每次请求返回的内容都不一样。摘要式认证过程需要两次交互来完成

1.第一次交互

客户端在向服务端发送请求后, 服务端会返回 401 UNAUTHORIZED, 同时在响应头中的 WWW-Authenticate 字段说明认证方式是 Digest, 其他信息还有 realm 域信息、 nonce 随机字符串、 opaque 透传字段(客户端会原样返回)等, 如下:

此处输入图片的描述

2.第二次交互

此时客户端会将用户名、密码、nonce、HTTP Method 和 URI 作为校验值进行 md5 散列计算,然后通过请求头再次发送给服务端, 服务端认证成功后就会返回如下的正常内容。

此处输入图片的描述

其中客户端请求头 Authorization 字段中的 response 值为加密后的密码,服务端通过该值来完成认证, 它的生成方式分三步计算:

(1) 对用户名、认证域 (realm), 以及密码的合并值计算 md5 哈希值, 结果记为 HAI。

此处输入图片的描述

(2) 对 HTTP 的请求方法, 以及URI的摘要的合并值计算 md5 哈希值, 结果记为 HA2。

此处输入图片的描述

(3) 按照下面的方式生成 response 值, 如下:

此处输入图片的描述

其实,基本式认证和摘要式认证都是比较脆弱的认证方式,它们都无法阻止监听和劫持攻击

2.HEAD 方法

HTTP 协议中有很多请求方法, 这里主要说一下HEAD方法。HEAD方法与 GET 方法相同,只不过服务器响应时不会返回消息体, 只有消息头。

注:
这个特性在我门的扫描过程中大有用处,如果阅读过 sqlmap 源码的效果版就知道 sqlmap 中就是通过使用 HEAD头获取页面返回长度而不需要将整个页面返回的,这样就大大降低了整个扫描对比的时间成本,提高了扫描效率。

下面我们用curl命令发送一个HEAD请求, 举例如下:

此处输入图片的描述

网站设置的 Cookie,在写爬虫的时候一定要考虑到将其带上,如果有 session 请记得采用未出会话的方式

4. DNS 本地缓存

浏览器在与 Web 服务器进行交互时,会向 DNS 服务器发送 DNS 查询,请求查找域名对应的 IP 地址。 在对一个域名进行爬取时, 如果每次都要对域名进行 DNS 查询解析, 就会浪费很多不必要的查询时间, 这时 DNS 缓存的作用就突显出来, 它可以将域名与 IP 对应的关系存储下来。 当再次去访问这个域名时, 浏览器就会从 DNS 缓存中把 IP 信息取出来, 不再去进行 DNS 查询, 从而提高了页面的访问速度。

DNS 本地缓存有两种形式:

(1)一种是浏览器缓存
(2)另一种是系统缓存

在浏览器中访问域名时,它会优先访问浏览器缓存。 一但未命中,则会访问系统缓存。 既然是缓存, 那么就会涉及有效时间。 系统缓存的 DNS 记录有一个 TTL 值 (time to live), 单位是秒, 意思是这个缓存记一个 TTL 值 (time to live), 单位是秒, 意思是这个缓存记录的最大有效时间。 而浏览器缓存的有效时间, 则是由各自厂商单独设置的, 不同种类的浏览 器, 缓存时间不尽相同, 比如: chrome 浏览器的缓存时间大约为 1 分钟。

(1)浏览器缓存

以 chrome 为例:

此处输入图片的描述

(2)系统缓存

Windows 下在 cmd 中输入 ipconfig /displaydns 可以查看

此处输入图片的描述

Linux 下 使用 nscd -g

此处输入图片的描述

5.页面解析

这里说的页面解析,主要是指对 HTTP 请求后的响应内容进行页面分析,并从中提取 URL 的过程。我们知道, HTTP 响应分为响应头和响应体,响应头的内容比较固定,解析也相对简单;而响应体则不一样,它的内容类型多种多样,不同内容的解析方式也不同, 因此需要根据响应体的内容类型来区别对待。

响应体的内容类型则是由响应头中的” Content-Type” 字段来指定的,它主要用千定义网络文件的类型和网页的编码, 常见的内容类型如下:

此处输入图片的描述

本文还是以 HTML 的解析为主

6.爬虫策略

爬虫在爬取的过程中会涉及到非常多的不同页面间的互相引用,如果没有一些机制的话就会出现爬取到的页面有很多的重复

(1)广度优先策略

此处输入图片的描述

(2)深度优先策略

此处输入图片的描述

(3)最佳优先策略(聚焦爬虫策略)

最佳优先策略,是一种启发式的爬行策略。它其实是广度优先策略的一种改进,在广度优先策略的基础上,用一定的网页分析算法,对将要遍历的页面进行评估和筛选,然后选择评估最优的一个或多个页面进行遍历,直至遍历所有的页面为止。

注意: 在很多情况下, 由于深度优先策略会导致爬虫的 "陷入”问题,即无法进行回退遍历,特别是对于大型的互联网网站,通常需要设置爬行的深度,否则爬虫在有限的时间内将无法爬完。 而且在实际的应用中,随着爬行深度的递增,有价值的URL也会相应减少。因此,深度优先策略并不太适用,目前爬虫通常选择的策略是广度优先策略和最佳优先策略。

7.页面跳转

很多情况下页面会进行跳转,这时候爬虫就要去 follow

(1)客户端跳转

客户端跳转通常也分为两种: 一种是301跳转,301代表永久性转移(Permanently Moved),另一种是302跳转, 302代表临时性跳转(Temporarily Moved)。 其实301跳转流程与302跳转流程 一样, 只不过状态码不同而已。

当客户端向服务端发送一个请求时,服务端会返回一个301或302的跳转响应,客户端浏览器在接收到这个响应后就会发生页面跳转, 它会根据这个响应头中”Location”字段所包含的地址,再次自动向服务端发送一个HTTP请求来完成跳转过程。

(2)服务端跳转

服务端在收到客户端的HTTP请求后,由于请求的页面和实际处理请求的页面不同,因此服务端会在内部进行页面跳转,我们称为服务端跳转。在这个过程中,其实服务端只收到客户端的一个HTTP请求,它对客户端来说是透明的,因此客户端看到的仍然是原始的URL, 响应的状态码也为200。,这似乎就是我们常见的 PHP 下的 include 的某种操作。

我们可以在Nginx中增加下面内容:

此处输入图片的描述

其中,abed.html是客户端发起的请求,而实际服务端处理和响应的是test.html这个页面, 如下图:

此处输入图片的描述

注意: 服务瑞跳转时,客户端只发送一次请求,浏览器的地址栏不会显示目标地址的URL,而客户端跳转时,由于是两次请求,这时地址栏中会显示目标资源的URL。

8.识别 404 页面

在爬行的过程中,爬虫需要识别404错误页面,并根据它来标记当前所爬行的URL是否有效或存在,这样就可以避免无效爬取,提高爬虫效率。 通常管理员在设置404错误页面时有下面两种情况:

1.直接在Web容器中设置404错误页面, 此时服务端返回 404状态码

2.将404错误页面指向一个新的页面, 在页面中使用301或302的方式重定向跳转到这个页面, 此时服务器返回301或302状态码。

所以从理论上而言, 404错误页面一般返回的状态码为:301、302或404; 但也不排除有的管理员设置特殊, 直接返回状态码为200的错误页面。所以,对于404错误页面的识别,不能简单根据状态码信息来判断。 具体的识别方法,后面章节会详细介绍。

9.URL重复/URL相似/URL包含

这三个概念主要用于爬虫对URL列表进行过滤,过滤掉一些对扫描器没有意义的URL,减少重复爬取的时间,提高扫描器整体的效率。 由于这些名词并不属于标准概念,因此笔者在下面先给出其定义。

(1)URL重复

URL重复,是指两个URL完全一样。具体来说,就是协议、主机名、端口、路径、参数名和参数值都相同。

(2)URL相似

URL相似,是指两个URL的协议、主机名、端口、路径、参数名和参数个数都相同。

(3)URL包含

URL包含,是指两个URL, 将它们分别记为A和B, 它们的协议、主机名、端口和路径都相同。

若A的参数个数大千或等千B, 那么B的参数名列表与A的参数名列表存在包含关系,其实URL相似可以看作URL包含的一种特例,A和B的参数相同。

10.区分相似和包含URL的意义

这里我们结合扫描器的场景来看,扫描器获取这些URL的目的主要是对它们进行安全漏洞审计,而安全漏洞审计的主要方式是对URL中的参数进行模糊测试(Fuzz testing)。

对于相似的URL检测,其实就是检查服务端上同一个文件的相同参数。 从漏洞检测的角度来看, 如果其中一个 URL 存在漏洞那么相似 URL 也存在漏洞

包含的URL也是同样的道理, 对千服务端上的同一文件只要检测不同的参数,对于相同的参数无需检查

11.URL去重

常见的方式有两种: 布隆过滤器和哈希表去重。

(1)布隆过滤器

布隆过滤器(Bloom-Filter), 是由布隆(Burton Howard Bloom)在1970年提出的。 它实际上是由一个很长的二进制向量和一系列随机映射函数组成的,可以用于检索一个元素是否在一个集合中。它的优点是空间和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。 因此,BloomFilter 不适合那些 “零错误” 的应用场合。而在能容忍低错误率的应用场合下,BloomFilter 比其他常见的算法(如Hash、折半查找)极大地节省了空间。

原理如下:

此处输入图片的描述

(2)哈希表去重

哈希表去重的做法比较简单,它通过建立一个哈希表,然后将种子URL放进去. 对于任何一个新的URL.首先它需要在哈希表中进行查找,如果哈希表中不存在,那么就将新的URL插入哈希表中,直至遍历完所有的URL, 最后哈希表中的内容就是去重后的URL。 这种方式去重效果精确,不会漏掉一个重复的URL, 但对空间的消耗也相应较大。 根据哈希表存放的位置,可以将其分为两种方式:一种是基于内存的Hash表去重另一种是基于硬盘的Hash表去重

1.基于内存的Hash表去重

这种方式直接在内存中对URL进行操作和去重,随着URL的增长,它消耗的内存空间也越来越多,然而内存大小是有瓶颈的,因此,它无法完成对大型网站的全站爬取。 但由于数据操作是直接在内存中执行的,所以,它的处理速度很快

在真实的爬取中,由于URL 是字符串形式,占用的字节数较多,按照保守估计,每个URL平均的长度为20 ,当然,URL越长占用的空间也就越大。这种情况下我们可以进行简单的优化,对URL进行压缩存储。

以md5哈希算法为例,md5运算后的结果是 128bit, 也就是16字节的长度,而且每个URL的长度都可以控制在16字节,这样就可以极人地减少存储空间的开销。

具体的操作方式为:对URL进行哈希运算,然后放到这个哈希表中,如果哈希值不存在千哈希表中, 就将该URL插入结果列表, 同时将哈希值插入哈希表, 直至遍历结束, 此时结果列表中就是去重后的URL。

2.基于硬盘的Hash表去重

它将URL存储在硬盘上,并在硬盘上对其进行去重。这样在处理海量URL的时候,就不用担心内存溢出的问题。这种方式有个成熟的解决方案,就是利用Berkeley DB进行基于硬盘的URL去重。

Berkeley DB是一个开源的文件数据库, 介于关系数据库与内存数据库之间, 使用方式与内存数据库类似, 它提供的是一系列直接访问数据库的函数, 是一个高性能的嵌入式数据库引擎,可以用来保存任意类型的键/值对(KeyNalue) , 而且可以为一个键值保存多个数据。

它支持数千个并发线程同时操作数据库,支待最大256TB的数据。 同时提供诸如C语言、C++、Java、Perl、Python等多种编程语言的API. 并且广泛支持大多数类Unix操作系统、Windows操作系统,以及实时操作系统(如:VxWorks)。

Berkeley DB实际是一个在硬盘上的 hash 表,我们可以使用压缩后的URL字符串作为Key,而对于Value可以使用Boolean,一个字节;实际上,Value是 个状态标识, 减少Value占用存储空间, 然后直接向Berkeley DB添加URL即可。 当遇到重复的URL时, 它就会通过返回值告知我们。

12.页面相似算法

在一些情况中,比如SQL注入检测,我们通常需要比较两个页面内容的关系,看看他们是否相似或相同,然后利用它们的差异性来判断输入对后端应用的影响。页面相这里主要介绍其中常用的两种: 编辑距离和Simhash

1.编辑距离

它是指两个字符串之间,由一个转成另一个所需的最少编辑次数,许可的方式是:插入、删除、替换。编辑距离的算法由俄国科学家Levenshtein提出,所以叫LevenshteinDistance。 一般来说,编辑距离越小,两个串的相似度越大。

2.Simhash

Simhash是Google用来处理海量文本去重的算法,它会为每一个Web文档通过Hash的方式生成一个64位的字节指纹,暂目称之为 “特征字 “,判断相似度时,只需判断特征字的海明距离是不是小于n (根据经验值,n一般取值为3)‘ 就可判断两个文档是否相似。

那么,什么叫海明距离呢?在信息编码中, 两个合法代码对应位上编码不同的位数称为码距,又称海明距离。

举例如下:
10101 和 00110 从第一位开始依次有第一位, 第四位和第五位不同,则海明距离为3

这里其实我还是有一个疑问,因为 hash 是杂凑函数,也就是页面只要改变一点点 hash就会变得完全不同,这就是所谓的雪崩效应,所以我不知道他是怎么做到小于 3 的

13.断连重试

在爬虫的爬行过程中,为了保证爬虫的稳定和健壮,必须要考虑网络抖动的因素。因此,我们需要增加断连重试机制。当连接断开时,爬虫需要尝试去重新建立新的连接,只有当连接断开的次数超过阀值时,才会认定当前的网络不可用。

14.动态链接与静态链接

这里所说的动态链接和静态链接,主要是针对URL而言的 它们可以通过URL的扩展名来区分。静态链接主要是指静态资源文件,扩展名主要为: rar、 zip、 ttf、 png、 gif等。 因为它们对获取新的URL并没有做出太多贡献,而且这类链接的数量又非常大, 因此,我们需要在新一轮爬取前过滤这些无意义的静态链接, 这样就可以极大地提高爬行效率

动态链接与静态链接是相反的,它所代表的页面中包含新的URL. 我们需要对其进行页面解析和URL提取操作, 这类链接的扩展名主要为: html 、 shtml、 do、 asp、 aspx、 php、 jsp等

0X03 web 爬虫进阶

1.web 爬虫的工作原理

Web爬虫,即从一个或若干个初始网页的URL开始,获得初始网页上的URL, 在抓取网页的过程中,不断从当前页面上抽取新的URL放入队列,直到满足一定的条件才会停止爬取。

从上面这段话可以看到,爬虫的工作原理其实很简单,根据内容定义可以很容易地给出Web爬虫的框架代码(Python版本),如下:

此处输入图片的描述
此处输入图片的描述

2.实现 URL 的封装

由于在爬取的过程中,爬虫不仅需要对URL进行频繁操作和处理,同时还需要获取URL 中的很多元信息,比如, 主机名、端口、根域名、文件名、扩展名和请求参数等。 因此,在这里我们需要对URL进行类封装, 这样可以方便后续对其进行统一的维护和改进。

在URL类中, 主要通过Python自带的URL解析模块(urlparse)来获取URL相关的属性信息, 部分实现代码如下:

URL 类:

此处输入图片的描述

URL 类方法:

此处输入图片的描述

然后我们就可调用这个类的方法去获取地址的各种部分了

此处输入图片的描述

3.HTTP 的请求和响应

我们的爬虫最基本的功能就是去请求页面然后获取页面的响应,为了方便我们依然对其进行封装

(1)Request 类

此处输入图片的描述

(2)Response 类

此处输入图片的描述

(3)wCurl 类

具体的请求和响应我们使用的是 wCurl 类来实现, wCurl 是一个基千 Requests 模块的二次封装,

此处输入图片的描述

(4)查询 dns 缓存

在 URL 爬取过程中,为了减少频繁地对域名进行 DNS 查询,我们可以根据本地缓存 DNS的查询结果进行优化。如果该域名已经查询过,那么就直接返回DNS查询结果,而不必向DNS服务器发送查询请求。 只有当该域名还没有被查询过的时候, 才会进行DNS查询, 并记录域名到IP的对应关系。 具体的代码实现如下:

此处输入图片的描述

(5)扫描速率控制

扫描速率的控制有两种方法实现:

1.是将需要发送的请求全部存入队列,然后新起一个线程,每隔一段时间从队列中取出一个请求进行发送,并对响应进行处理

2.使用 HOOK 的方式行处理,对socket中的connect函数进行HOOK,在请求发送之前进行时间间隔的统一控制和处理,从而实现扫描速率的控制。

由于HOOK的方式便于理解和操作,因此,这里就以HOOK方式来实现。在对connect函数进行HOOK之前,先举个例子, 便于读者理解。下面有个函数show() , 对其进行HOOK,在函数show()运行之前,打印出当前的时间,由于该例子用到Python 中的apply函数, 这里先介绍一下该函数的用法。

apply(func[,args [,kwargs]])函数用于当函数参数已经存在于一个元组或字典中时,间接地调用函数。args是个包含将要提供给函数的按位置传递的参数的元组。举例说明一下,假如函数A的位置为A(a= l,b=2) ,那么这个元组中就必须严格按照这个参数的位置顺序(a=3,b=4)进行传递,而不能是(b=4,a=3)这样的顺序。kwargs是个包含关键字参数的字典, 如果args不需要传递,kwargs需要传递,那么必须在args的位置留空,apply的返回值就是func函数的返回值。 如果直接省略了args, 那么任何参数都不会被传递。

下面我们来看看如何 利用apply函数进行HOOK操作, 代码实现如下:

此处输入图片的描述
此处输入图片的描述

具体实现如下

此处输入图片的描述

4.页面解析

(1)HTML 解析库

在 Python 环境中,常用的 HTML 解析库有 HTMLParser、 lxml 和 html5lib 等,可以使用它 们进行页面解析和 URL提取,其各自的特点如下:

此处输入图片的描述

1.HTMLParser

它是Python中内置的用来解析HTML的模块,可以分析出HTML里面的标签、数据等,通过HTMLParser处理HTML非常简便。 HTMLParser采用的是一种事件驱动的模式, 当HTMLParser找到一个特定的标记的时候就会调用用户自定义的函数,并以此来通知程序处理,其中用户定义的回调函数都是以handler_开头命名的

2.lxml

lxml 是Python处理XML和HTML相关功能最丰富和最容易使用的库。lxml是libxml2和libxslt 库的一个Python化的绑定。它与众不同的地方是兼顾了这些库的速度和功能的完整性,以及纯PythonAPI的简洁性。由于爬虫通常需要处理的页面很多, 所以这里我们选择速度快和容错能力强的lxml库对HTML进行解析。

3.html5lib

html5lib是一个通过Ruby和Python解析HTML文档的类库,支待HTML5并最大程度兼容桌面浏览器。在页面解析中,我们而要处理两个主要问题:一个是URL提取:另一个是自动填表。也就是说,当碰到页面中有FORM表单时, 需要完成对表单内容的自动填充,然后再发送给服务端。

(2)URL 提取

URL提取来源于HTTP响应头和HTTP响应体。

1.HTTP响应头

当响应的状态码为301或302时,响应头中会有Location字段,它的值中会有URL信息,如下:

此处输入图片的描述

当然可能还会有一些其他自定义字段包含URL 信息,所以需要从HTTP 响应头中提取URL

2.HTTP响应体

响应体中的URL 提取比较简单, 这里有两种常用的方式:

(1)利用URL正则对响应体的内容进行全文匹配,找出其中所有的URL信息;
(2)对HTML 进行解析,遍历存在URL的标签,如:超链接标签<a>、表单标签<form>和脚本标签<script>等,获取这些标签的属性值即可。

利弊分析:

(1)第一种方式由于是通过正则匹配来获取,URL 的准确度较差,只能够获取一些标准格式的URL:
(2)第二种方式是通过标签的属性值来获取URL, 理论上准确度会高些, 但可能会漏掉页面的一些URL。因此, 这里面结合两种方式来提取URL。

下面就利用lxml 的HTML解析器来对HTTP 响应进行解析并提取其中的URL 信息。HTML解析器在对HTML文档解析中会隐式触发一些函数, 比如,当解析器遇到HTML 标签调用时,如: <a href="http://www.baidu.com">, 就会调用函数handle_a_tag_start(tag,attrs), 其中参数tag 是标签名,attrs 为标签所有的属性,并按照(name,value) 的元组以列表形式存储,这里attrs 值为: [('href','http://www.baidu.com')] ,当遇到对应结束标签时,如: </a>, 就会调用函数handle_a_tag_end(tag), 因此,可以重载这些处理函数来完成URL提取,部分核心代码如下:

此处输入图片的描述

此处输入图片的描述

(3)自动填表

为了实现自动填写表单的功能, 需要建立常见表单字段与内容的对应关系,并生成表单知识库如果表单字段存在于该知识库中,那么就可以用对应的内容进行填充,从而完成自动填表的功能。 常见的表单字段信息如下:

此处输入图片的描述

显然, 如果上述表单知识库中的表单字段越多, 那么自动化填写的能力就越强。这里主要是为了讲解原理和实现功能, 就不继续丰富表单知识库了,暂且以现有的这些表单字段来实现自动填表, 部分实现代码如下:

此处输入图片的描述

5.URL 去重去似

(1)URL 去重

前面提到过的两种方式:布隆过滤器和 hash表

1.布隆过滤器

这里有两个实现布隆算法的Python模块,可以直接使用它们进行URL去重,如下

Python-bloomfilter

Github 地址为 https://github.com/jaybaird/Python-bloomfilter

Pybloomfiltermmap

Github 地址为 https://github.com/axiak/pybloomfiltermmap
官方文档为:https://axiak.github.io/pybloomfiltermmap/

这里我们用 Pybloomfiltermmap 模块进行介绍,

在Pybloomfiltermmap模块中,实现了两类布隆过滤器: Bloomfilter和ScalableBloomfilter

其中,Bloomfilter是个定容的过滤器,error_rate是指最大的误报率;ScalableBloomfilter是一个不定容最的布隆过滤器,它可以不断添加元素。方法add是添加元素,如果元素已经在布隆过滤器中,那么返回True;如果不在,那么返回False

具体实现:

此处输入图片的描述

2.Hash 表去重

还可以用Hash表去重,其原理非常简单,通过遍历原URL列表,判断每个URL是否在去重后的列表中,如果不在列表中,那么彻添加到去重后的列表中;如果在列表中,那么直接忽略即可,具体方法如下。

方法一:利用内存 Hash 表去重

此处输入图片的描述

在实际的爬行中, URL的长度其实并不固定, 而且随着爬行深度的增加, 单个URL的长 度会越来越长。如果此时仍然使用URL作为Key值进行去重,显然不太合理,这样内存和性能都会损耗过快。此时可以对URL进行Hash运算压缩, 比如:16位的md5运算。 这样就可 以把URL的长度固定为16字节,从而提高去重的效率, 如下:

此处输入图片的描述
此处输入图片的描述

方法二:利用 BerkeleyDB 去重

首先, 从Oracle官网(http://www.oracle.com/technetwork/cn/database/database-technologies/ berkeleydb/downloads/index.html)下载Berkeley DB的源码。

还需要安装Python的bsddb3模块。 它提供了BerkeleyDB数据库的操作接口,这样就可以在Python中使用该数据库了

具体实现:

此处输入图片的描述

(2)URL 去似去含

具体实现:

此处输入图片的描述
此处输入图片的描述

测试代码:

此处输入图片的描述

6.404 页面的识别

404页面识别并不能简单地靠状态码信息,首先需要建立404页面知识库,然后从状态码和页面内容两个维度进行准确识别,这样就可以极大地提高404页面识别的准确度(这里的知识库我理解就是数据对应关系的意思,或者理解为数据库)。

我们可以通过随机构造一些明显不存在的网站来触发目标的 404 页面, 比如:tscrumer_404_nofound.html、no_exists_for_test.html等。在实际的文件名构造中,可以加入随机因子, 避免重名问题,然后将这些页面的特征进行提取和存储,建立对应的404页面知识库。

具体方法:

可以通过状态码,以及与现有的 404 页面知识库进行404 页面识别。 具体的识别逻辑为:如果当前页面的状态码为 404, 那么它为 404 页面;如果当前页面的状态码不是 404,那么将该页面与 404 页面知识库中的页面进行内容相似度比较,如果相识度高千阙值,那么判定当前页面为404 页面。 部分核心代码实现如下

此处输入图片的描述

7.断连重试

在使用 Requests 模块进行网络通信时,如果网络连接不可用或断开,那么该模块会抛出相应的异常,我们可以通过捕获异常来实现断连重试的功能。为了对爬虫程序的结构影响最小,这里可以利用Python 中的装饰器来实现断连重试

此处输入图片的描述

8.爬虫实现

至此,爬虫的基础功能都已实现了,下面就根据爬虫的结构,将这些功能进行整合,实现最终版本的Web爬虫,这里用Crawler类对Web爬虫进行封装实现,部分实现代码和结构如下:

# coding=utf-8
'''
crawler.py
'''
import sys
import traceback
import itertools
import time
from Queue import Queue

from LogManager import log as om

# HTTPRequest
from teye_web.http.URL import URL
from teye_web.http.Request import Request
from teye_web.http.Response import Response

# url function
from teye_web.http.function import is_similar_url

# Document Parser
import teye_web.parser.dpCache as dpCache

# wCurl
from wCurl import wcurl

# 404 Check
from teye_util.page_404 import is_404


class Crawler(object):
    def __init__(self, depth_limit=1, time_limit=30, req_limit=100, filter_similar=True):
        '''
        '''
        self.root = ''

        self._target_domain = ''

        self.depth_limit = depth_limit
        self.time_limit = time_limit
        self.req_limit = req_limit

        self._sleeptime = 1

        self._url_list = []

        self._already_visit_url = set()

        self._already_seen_urls = set()

        self._already_send_reqs = set()

        self._relate_ext = ['html', 'shtm', 'htm', 'shtml']

        self._white_ext = ['asp', 'aspx', 'jsp', 'php', 'do', 'action']

        self._black_ext = ["ico", "jpg", "gif", "js", "png", "bmp", "css", "zip", "rar", "ttf"]

        self._blockwords = ['mailto:', 'javascript:', 'file://', 'tel:']

        self.num_urls = 0

        self.num_reqs = 0

        self._wRequestList = []

        self._start_time = None

        self._other_domains = set()

    def get_discovery_time(self):
        '''
        爬虫爬行的时间,单位为:分钟
        '''
        now = time.time()
        diff = now - self._start_time

        return diff / 60

    def _do_with_reqs(self, reqs):
        '''
        '''
        result = []
        count = len(reqs)

        if reqs is None or count == 0:
            return result

        for i in xrange(count):
            filter = False
            filter_url = reqs[i].get_url()
            for j in xrange(count - i - 1):
                k = i + j + 1
                store_url = reqs[k].get_url()
                if is_similar_url(filter_url, store_url):
                    filter = True
                    break
            if not filter:
                result.append(reqs[i])

        return result

    def _get_reqs_from_resp(self, response):
        '''
        '''
        new_reqs = []

        try:
            doc_parser = dpCache.dpc.getDocumentParserFor(response)

        except Exception, e:

            pass

        else:

            re_urls, tag_urls = doc_parser.get_get_urls()

            form_reqs = doc_parser.get_form_reqs()

            seen = set()

            for new_url in itertools.chain(re_urls, tag_urls):

                if new_url in seen:
                    continue

                seen.add(new_url)

                if new_url.get_host() != self._target_domain:
                    if new_url.get_host() not in self._other_domains:
                        self._other_domains.add(new_url.get_host())
                    continue

                if new_url not in self._url_list:

                    self._url_list.append(new_url)

                    wreq = self._url_to_req(new_url, response)

                    if wreq not in self._wRequestList:
                        new_reqs.append(wreq)

                        self._wRequestList.append(wreq)

            for item in form_reqs:

                if item not in self._wRequestList:
                    new_reqs.append(item)

                    self._wRequestList.append(item)

            return new_reqs

    def _url_to_req(self, new_url, response, method="GET"):
        '''
        '''
        req = Request(new_url)
        req.set_method(method)

        new_referer = response.get_url()
        req.set_referer(new_referer)

        new_cookies = response.get_cookies()
        req.set_cookies(new_cookies)

        return req

    def crawl(self, root_url):
        '''
        将URL对象存入到队列
        '''
        if not isinstance(root_url, URL):
            root_url_obj = URL(root_url)
        else:
            root_url_obj = root_url

        self._target_domain = root_url_obj.get_host()

        self._url_list.append(root_url_obj)

        root_req = Request(root_url_obj)

        q = Queue()

        q.put((root_req, 0))

        self._start_time = time.time()

        while True:

            if q.empty():
                print "reqs empty break"
                break

            this_req, depth = q.get()

            # 将静态链接进行过滤
            if this_req.get_url().get_ext() in self._black_ext:
                continue

            # 控制爬行的深度
            if depth > self.depth_limit:
                print "depth limit break"
                break
            # 控制爬行的时间
            if self.get_discovery_time() > self.time_limit:
                print "time limit break"
                break

            # 控制爬行的链接数,避免内存泄露
            if self.num_reqs > self.req_limit:
                print "reqs num limit break"
                break

            if this_req in self._already_send_reqs:
                continue

            try:
                self._already_send_reqs.add(this_req)

                om.info("%s Request:%s" % (this_req.get_method(), this_req.get_url().url_string))

                response = None

                try:
                    response = wcurl._send_req(this_req)

                except Exception, e:
                    print str(e)
                    pass

                if is_404(response):
                    continue

                if response is None:
                    continue
                # 获取HTTP响应中的请求
                new_reqs = self._get_reqs_from_resp(response)
                # 过滤相似和包含的请求
                filter_reqs = self._do_with_reqs(new_reqs)

                depth = depth + 1
                for req in filter_reqs:
                    q.put((req, depth))

                self.num_reqs = len(self._already_send_reqs)
                om.info("Already Send Reqs:" + str(self.num_reqs) + " Left Reqs:" + str(q.qsize()))

            except  Exception, e:
                traceback.print_exc()
                om.info("ERROR: Can't process url '%s' (%s)" % (this_req.get_url(), e))
                continue

            time.sleep(self._sleeptime)

        return self._do_with_reqs(self._wRequestList)


if __name__ == "__main__":
    '''
    '''
    # url1="http://testphp.vulnweb.com/showimage.php"
    # url2="http://testphp.vulnweb.com/showimage.php?id=1"
    # print is_similar_url(url1,url2)
    # sys.exit()
    w = Crawler()
    wurl = "http://192.168.1.105:8080/wavsep/active/index-sql.jsp"
    a = w.crawl(wurl)
    for item in a:
        # print "\r\n"
        # print item
        print item.get_url()

    print "Found URL Num:" + str(len(a))
    sys.exit()

9.web 2.0 爬虫(重中之重)

(1)基本概念

在Web 1.0时代,网站主要是基千静态页面来构建的,以单向内容输出为主。到了Web2.0时代, 随着动态脚本的兴起和Ajax技术的发展(Ajax全称为”Asynchronous JavascriptAndXML”,异步JavaScript和XML, 它是一种创建交互式应用的网页开发技术), Web站点的架构和交互场景也发生了变化,网站中融入了更多的动态交互和事件触发, 这也给传统的Web爬虫提出了新的挑战,因为通过正则匹配的爬取方式显然已经力不从心,它们无法爬取到Web 2.0中的异步请求和事件请求。在传统Web爬虫的视角里, 每一个URL代表了站点中的一个页面,新页面的URL是可以很容易地通过正则匹配爬取的。但在Ajax应用中, 这种情况则发生了改变,一个页面中会有不同的状态,每个状态代表着不同的页面,因此这些状态也需要被爬取到。由于状态之间的跳转是通过交互的方式进行触发的, 而传统爬虫并不具备交互的能力,所以它无法进行感知和爬取。

在了解Web2.0爬虫产生的背景后,我们还需要清楚Ajax的工作方式和特点,其实Ajax不是种新的编程语言,而是 种用千创建更好、 更快, 以及交互性更强的Web应用程序的技术。 它使用 JavaScript向服务器提出请求, 并处理响应而不阻塞用户, 核心对象为 XMLHTTPRequest。通过这个对象,JavaScript可在不重载页面的情况下与Web服务器交换数据,

工作原理如下:

此处输入图片的描述

通俗来讲,Ajax就是一种异步通信请求方式,它允许页面内容可以动态地触发和加载,所以我们在浏览器中看到的页面其实是不完整的,它只能算是完整页面中的一个状态, 只有遍历当前页面中所有的状态后, 才能完整地获取当前页面中所有的URL。

(2)要解决的问题

从上面的内容可以得出: 与传统爬虫相比, Web 2.0爬虫的核心在于页面事件的触发和页面状态的保持,但要想满足这两个条件, 则需要解决以下5个主要问题:

1.执行JavaScript代码

由于Ajax应用的功能实现依赖于JavaScript代码在Web客户端的执行, 因此Ajax爬虫必须能够执行JavaScript代码, 所以需要添加一个JavaScript脚本解释器。

2.页面DOM树操作

对页面内容进行DOM树解析, 可以对标签进行动态的操作。

3.页面事件触发

由于一些标签包含事件属性, 需要对这些事件触发完成交互, 才能获取新的页面

4.页面状态保持

传统的Web站点中每一个URL标志着一个静态页面, 而在Ajax应用中, 一个页面有很多状态的变化, 每当触发一个事件, 都会导致页面发生变化, 所以Web2.0爬虫需要记录这些页面状态,以便对变化的页面进行URL提取。

5.重复事件识别

Ajax应用中某些事件可能由同一个JavaScript函数来处理,触发这些事件可能导致相同状态,而这些重复的事件触发会给服务器带来不必要的负载,所以当同一页面在进行状态变化时,需要记录和识别这些重复事件,避免重复触发和爬取。

说了那么多枯燥的理论,下面我们就来看一下如何实现Ajax爬虫。为了降低技术难度,可以使用浏览器引擎来实现(在小型的爬虫中可以选择使用 python 的 Ghost 对 webkit 的封装这种技术,但是这种技术只能说是一种玩具类型的,不能适用于大型项目,这本是这里使用的是 ghost 技术,但是我更推荐使用 headless chrome),也许会有读者问,为什么不直接使用JavaScript引擎来处理呢?主要是因为单纯的JavaScript引擎虽然可以执行JS代码,但它无法与DOM树关联起来,也无法有效地对DOM树进行操作。而在浏览器引擎的环境下,JavaScript引擎与DOM树有上下文环境,JS代码可以直接对DOM树进行操作, 所以它可以很好地解决 “执行JavaScript代码” 和 “面DOM树操作” 两个难题,让我们可以更加专注千解决Ajax爬虫的核心问题。

概念: 页面状态深度
页面状态深度就是对当前页面进行事件触发时,页面新产生的内容中仍然存在需要触发的事件,我们把每次的事件触发称之为一个深度

下面是AWVS (Acunetix Web Vulnerability Scanner)提供的Web2.0测试页面,由于它具有代表性,所以这里用它进行说明。我们来看一下, 当单击其中一个链接时, “artists”前后的变化。

单击链接前的页面, 如下图:

此处输入图片的描述

单击链接后的页面, 如下图:

此处输入图片的描述

可以看到,单击后页面在id为contentDiv的层中新增了内容,而这些新增的内容同样需要进行事件触发才能获取后续的URL, 每一次事件的触发称之为一个深度,当前这个测试页面的状态深度为2, 也就是说,它需要两次事件触发才能完整地获取页面的所有URL。

此处输入图片的描述

下面我们先来梳理一下 Web2.0爬虫的工作流程, 这里用伪代码来说明, 并将页面的状态深度设置为2,实现伪代码如下:

此处输入图片的描述

由于篇幅原因,这里先按照书本上的 ghost 这种原始的不优雅的技术内容进行介绍(当然从这种技术上也能看出爬虫编写的基本思想,也是很不错的),对于 headless chrome 我后面再单独写文章介绍吧(又是给自己留坑了……)。

有了上面的伪代码后, 现在开始进行具体的实现, 这里仍然以AWVS提供的Ajax测试站点(http://testphp.vulnweb.com/Ajax/index.php)为目标。

首先通过Python的Ghost模块引入浏览器引擎,并利用Ghost对象的Open函数打开目标站点,通过下面三行代码即可实现, 如下:

此处输入图片的描述

运行效果如下图:

此处输入图片的描述

其实Ghost 模块是对WebKit 浏览器引擎的封装,在页面载入的过程中, 它实际上已经完成了DOM 树解析、JS 解析,以及CSS 渲染等一系列工作,这样我们才能看到上图中网页的页面。但是在这里,我们更关心的是HTTP 请求。通过查阅PyQt 和Ghost 的官方文档,我们知道,res对象中存储的内容就是当前页面所发起的网络请求信息, 这时可以将当前请求的URL打印出来,如下:

此处输入图片的描述

此处输入图片的描述

这样每个页面不需要事件触发,主动发起的异步请求信息就可以通过res 对象来获取, 通过其属性值即可获取对应的URL。接下来看一下如何获取需要交互的URL。我们先看一下测试站点的网页源代码,页面中存在哪些需要交互的事件, 如下:

此处输入图片的描述

从上面方框中的内容可以看到,事件交互的操作主要体现在a标签中。 当单击a标签后,它就会触发对应的JavaScript函数执行, 当函数执行后,页面的内容就会发生变化,这时就可以获取新的URL。 因此,在这里可以通过模拟单击对应的a标签,然后通过res对象获取异步请求的URL信息,并通过更新后的页面内容获取新的事件交互链接,如下:

此处输入图片的描述

这样就可以完整地获取单击后发送的异步请求和页面更新后的新页面内容,从而成功地完成一次交互爬取。有了这个成功的经验,接下来就进一步考虑如何对页面进行完整的动态爬取。测试页面中有很多的链接,而且每次单击后页面的内容都会发生变化,显然它并不像单次爬取那么容易,下面就一起来整理一下完整的爬行思路如下:

(I)在当前页面HO中,遍历所有的a标签对象,对其进行循环事件触发(如:鼠标单击)。
(2)事件触发后,获取浏览器对外新发送的HTTP请求,并记录对应的URL。 同时获取当前的页面内容,记为H1. 并利用正则匹配出页面的URL。
(3)在第一次单击后形成的页面H1中, 再次遍历新的a标签, 对其进行循环事件触发。
(4)第二次事件触发后,同样获取浏览器对外新发送的HTTP请求, 并记录URL列表。获取当前的页面内容, 记为H1. 并利用正则匹配出页面的URL。

但这里是有问题的, 细心的读者也许会发现,当我们在第二次遍历新的a标签时,由千无法提前知道a标签的其他唯一属性,如:id、name等,所以只能通过getElementsByTag方法获取存放a标签对象的数组,并通过数组的下标来唯一标识, 伪代码如下:

此处输入图片的描述

而每次进行事件触发后,页面的内容是会发生变化的,这时页面中就会有新的a标签产生,它会导致使用 getElementsByTag(“a”) 的方式所获取到的 a 标签数组不一样, 因此不能使用
getElementsByTag(“a”)[i]来唯一标识特定的a标签对象。

那么, 如何进行改进呢?

这里主要是由于页面内容更新后无法对a标签进行唯一标识导致的, 因此,可以为每个页面的a标签增加一个唯一标识的属性。当页面发生变化时,就可以通过对新页面的a标签与变化前页面的a标签进行比较,计算得出新增的a标签,然后再单独对新增的a标签进行遍历操作,这样就不会使a标签数组紊乱了。可以使用a标签的href和onclick两个属性值来构造Hash作为唯一的标识。

我们需要在页面解析的代码中,增加对a标签的处理,为其增加唯一的识别标识, 如下:

此处输入图片的描述

增加唯一标识后, 再来看看核心部分的完整代码, 如下:

此处输入图片的描述
此处输入图片的描述

下面还需要扩展单击事件触发的操作,可以在Ghost的客户端脚本工具文件utils.js中增加 下列代码:

此处输入图片的描述